package edu.mercer.sse.ldap;

import edu.mercer.sse.app.Config;

import edu.mercer.sse.database.DB2LDAPLayout;
import edu.mercer.sse.database.DBConnector;
import edu.mercer.sse.errors.*;

import java.security.GeneralSecurityException;

import javax.net.ssl.SSLSocketFactory;

import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;

import com.unboundid.ldap.sdk.AddRequest;
import com.unboundid.ldap.sdk.Attribute;
import com.unboundid.ldap.sdk.DeleteRequest;
import com.unboundid.ldap.sdk.LDAPConnection;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.LDAPSearchException;
import com.unboundid.ldap.sdk.Modification;
import com.unboundid.ldap.sdk.ModificationType;
import com.unboundid.ldap.sdk.ModifyRequest;
import com.unboundid.ldap.sdk.ResultCode;
import com.unboundid.ldap.sdk.SearchRequest;
import com.unboundid.ldap.sdk.SearchResultEntry;
import com.unboundid.ldap.sdk.SearchScope;
import com.unboundid.ldap.sdk.LDAPConnectionOptions;
import com.unboundid.ldif.LDIFException;
import com.unboundid.util.ssl.SSLUtil;
import com.unboundid.util.ssl.TrustAllTrustManager;
import com.google.common.base.Optional;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Range;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;


/**
 * 
 * Utility LDAP related methods for database->LDAP publishing.<p>
 *
 */

public class LDAPUtility implements AutoCloseable 
{
	/** uid or equivalent LDAP field from configuration */
	
	private final static String UID_KEY = Config.settings().getProperty(
			"ldap-member-id");
	
	/** LDAPConnector: LDAP connection */
	
	private final static LDAPConnector _ldap = new LDAPConnector();

	/** DBConnector: database connection */
	
	private final static DBConnector _database = new DBConnector();
	
	/** Log */
	
	private final static CustomLogger _log = LoggerCustom.obtainLog( 
			"LDAPUtility", Config.getLogLevel());

	
	/**
	 * Constructor.<p>
	 * 
	 * @param credentials - LDAPCredentials login, host information
	 */
	
	private LDAPUtility( final LDAPCredentials credentials ) 
	{
		super();

		checkArgument( credentials.isSet(), "incomplete ldap credentials" );

		 _ldap.withCredentials( credentials );
		
	}//constructor


	/**
	 * Factory.<p>
	 * 
	 * @param credentials - LDAPCredentials login, host information
	 * 
	 * @return  new LDAPUtility instance
	 */
	
	public static LDAPUtility factory( final LDAPCredentials credentials ) 
	{
		return new LDAPUtility( credentials );
		
	}//method

	
	/**
	 * Provide LDAP login, host for this instance's LDAPConnector.<p>
	 * 
	 * @param credentials - LDAPCredentials login, host information
	 * 
	 * @return  this
	 */
	
	public LDAPUtility withCredentials( final LDAPCredentials credentials )
	{
		checkArgument( credentials.isSet(), "incomplete ldap credentials" );

		 _ldap.withCredentials( credentials );
		 
		return this;
		
	}//method
	
	
	/**
	 * Connect to LDAP as test.<p>
	 * 
	 * Predicate result of LDAP Host, login dn and other known valid 
	 * 	literals, duplicated, stand-alone logic.<p>
	 * 
	 * @return Boolean - T if connection was established without error
	 * 
	 */
	
	public Boolean ldapOK()
	{
		Optional<LDAPConnection> ldapCon = Optional.absent();
		
		Boolean result = false;
		
		try
		{
			final LDAPConnectionOptions ldapConOpt = new 
					LDAPConnectionOptions();

			ldapConOpt.setBindWithDNRequiresPassword( true );
			ldapConOpt.setConnectTimeoutMillis( 9000 );
			ldapConOpt.setCaptureConnectStackTrace( true );		

			final SSLUtil sslUtl = new SSLUtil( new TrustAllTrustManager() );
			
			final SSLSocketFactory sslSkFactory = sslUtl
					.createSSLSocketFactory();

			ldapCon = Optional.of( new LDAPConnection( sslSkFactory, 
					ldapConOpt) );

			ldapCon.get().connect( _ldap.getCredentials().host(), 
								   _ldap.getCredentials().port() );

			ldapCon.get().bind( _ldap.getCredentials().admin() , 
								_ldap.getCredentials().password() );

			result = true;
			
		}
		catch( final GeneralSecurityException | LDAPException err )
		{
			result = false;
			
			new CriticalException(err).accept( new ErrorLoggingVisitor() );
			
		}
		finally
		{
			ldapCon.get().close();
			
		}//try
		
		return result;

	}//method
	
	
	/**
	 * Check LDAP for a full DN String.<p>
	 * 
	 * Determine if an entry exists in LDAP.<p>
	 * 
	 * @param entryDn - full LDAP DN String: uid=doe_j,ou=group,dc=ibm,dc=com
	 * 
	 * @return  Boolean true if exists
	 * 
	 * @throws LDAPException - general LDAP or network error
	 * 
	 * @throws GeneralSecurityException - SSL sockets or other permission 
	 * 	error
	 */
	
	public static Boolean isPresent( final String entryDn ) 

			throws LDAPException, 
			   	   GeneralSecurityException
	{		
		SearchResultEntry ldapEntry = LDAPConnector.con().searchForEntry( 
			new SearchRequest(entryDn, 
							  SearchScope.SUB, 
							  Config.settings().getProperty(
									  "ldap-entries-filter")));		

		return ldapEntry != null ? true : false;   

	}//method


	/**
	 * Check LDAP for an entry from database or entry derived from LDAP.<p>
	 * 
	 * Determine if an entry exists in LDAP.<p>
	 * 
	 * @param entry - ImmutableMap&#60;String,String&#62; with keys as LDAP 
	 * 	keys or as database columns as specified in database->LDAP layout.<p>
	 * 
	 * @return  Boolean true if exists
	 * 
	 * @throws LDAPException - general LDAP or network error
	 * 
	 * @throws GeneralSecurityException - SSL sockets or other permission 
	 * 	error
	 * 
	 * @see DB2LDAPLayout
	 */
	
	public static Boolean isPresent( final ImmutableMap<String,String> entry ) 

			throws LDAPException, 
			   	   GeneralSecurityException,
			   	   SQLException
	{		
		return isPresent( findDN( entry ) );

	}//method
	
	
	/**
	 * Determines LDAP DN for given entry.<p>
	 * 
	 * Convenience method.
	 * 
	 * Accepts ImmutableMap&#60;String,String&#62; with keys as LDAP keys or
	 * 	as database columns as specified in database->LDAP layout.<p>
	 * 
	 * @param entry - ImmutableMap&#60;String,String&#62; from database or 
	 * 	derived from LDAP
	 * 
	 * @return  String containing full LDAP DN
	 * 
	 * @throws LDAPException  general LDAP or network error
	 * 
	 * @throws GeneralSecurityException  SSL or other socket creation error 
	 * 
	 * @throws SQLException  general database or network error
	 * 
	 * @see DB2LDAPLayout
	 */
	
	public static String findDN( final ImmutableMap<String,String> entry ) 

			throws SQLException,
				   LDAPException, 
				   GeneralSecurityException				   
	{		
		String uidValue = "";

		if( entry.containsKey(UID_KEY) )
		{ 
			uidValue = entry.get( UID_KEY );

		}
		else if( entry.containsKey( DB2LDAPLayout.ldapKeyAsdbCol( UID_KEY )))
		{ 
			uidValue = entry.get( DB2LDAPLayout.ldapKeyAsdbCol( UID_KEY ));

		}//if

		checkArgument( uidValue.length() > 0, "no ldap " + UID_KEY	+ " in "
				+ "entry" );

		/* get user dn */

		final String entryDn = "uid=" + uidValue 
				+ ","
				+ Config.settings().getProperty("ldap-entries-ou")
				+ "," 
				+ Config.settings().getProperty("ldap-base-dn");		
		
		return entryDn;   

	}//method

	
	/**
	 * Determines LDAP DN for given entry.<p>
	 * 
	 * Convenience method.
	 * 
	 * Accepts Map&#60;String,String&#62; with keys as LDAP keys or as 
	 * 	database columns as specified in database->LDAP layout.<p>
	 * 
	 * @param entry - Map&#60;String,String&#62; from database or 
	 * 	derived from LDAP
	 * 
	 * @return  String containing full LDAP DN
	 * 
	 * @throws LDAPException  general LDAP or network error
	 * 
	 * @throws GeneralSecurityException  SSL or other socket creation error 
	 * 
	 * @throws SQLException  general database or network error
	 * 
	 * @see DB2LDAPLayout
	 */
		
	public static String findDN( final Map<String,String> entry ) 

			throws SQLException,
				   LDAPException, 
				   GeneralSecurityException				   
	{	
		return( findDN( ImmutableMap.copyOf( entry )));
		
	}//method


	/**
	 * Determines LDAP DN for given entry.<p>
	 * 
	 * Convenience method.
	 * 
	 * Accepts Map&#60;String,String&#91;&#93;&#62; with keys as LDAP keys or 
	 * 	as database columns as specified in database->LDAP layout.<p>
	 * 
	 * Converts into Map&#60;String,String&#62; given Integer position of 
	 * 	String element to be copied over.<p>
	 * 
	 * @param entry - Map&#60;String,String&#62; from database or 
	 * 	derived from LDAP
	 * 
	 * @param valuePosition - Integer position of String array element
	 * 
	 * @return  String containing full LDAP DN
	 * 
	 * @throws LDAPException  general LDAP or network error
	 * 
	 * @throws GeneralSecurityException  SSL or other socket creation error 
	 * 
	 * @throws SQLException  general database or network error
	 * 
	 * @see DB2LDAPLayout
	 */
	
	public static String findDN( final Map<String,String[]> entry, 
								 final Integer valuePosition ) 

			throws SQLException,
				   LDAPException, 
				   GeneralSecurityException				   
	{	
		final Range<Integer> validPositions = Range.closed( 0, 
				entry.size()-1 );
		
		checkArgument( validPositions.contains( valuePosition ), 
				"String[] array position invalid: " 
				+ valuePosition 
				+ " out of range " 
				+ validPositions );
		
		Map<String,String> entryConverted = new HashMap<>();
		
		for( String entryKey : entry.keySet() )
		{
			entryConverted.put( entryKey, entry.get(entryKey)[valuePosition] );
			
		}//for
		
		checkArgument( entryConverted.size() == entry.size(), "empty entry "
				+ "after converting values from String[] to String" );
		
		return( findDN( ImmutableMap.copyOf( entryConverted )));
		
	}//method
	
	
	/**
	 * Searches LDAP for an entry.<p>
	 * 
	 * Entry can have LDAP keys or database columns.<p>
	 * 
	 * @param entrySought - entry collection of LDAP attributes or from 
	 * 	database  
	 * 
	 * @return  Map&#60;String,String&#62; containing entry as in LDAP, only 
	 * 	first attribute entry of any multi-value attributes is returned
	 * 
	 * @throws LDAPException  general LDAP or network error
	 * 
	 * @throws LDAPSearchException  error while searching LDAP
	 * 
	 * @throws GeneralSecurityException  SSL or other socket creation error 
	 * 
	 * @throws SQLException  general database or network error
	 * 
	 * @see DB2LDAPLayout
	 */
	
	public static ImmutableMap<String,String> searchForEntry( final 
			Map<String,String> entrySought ) 
					
			throws LDAPSearchException, 
				   LDAPException, 
				   GeneralSecurityException, 
				   SQLException
	{	
		checkNotNull( entrySought, "null entry" );
		
		checkArgument( entrySought.size() > 0, "empty entry" );
		
		/* Map argument value may be read-only, make mutable copy */
		
		Map<String,String> entry = new HashMap<>();
		
		entry.putAll( entrySought );

		checkArgument( entry.size() == entrySought.size(), 
				"failed to copy entry to mutable Map" );

		/* replace db columns with LDAP keys if needed */
		
		if( ! entry.containsKey(UID_KEY) )
		{
			entry.clear();

			for( String key : entrySought.keySet() )
			{
				entry.put( DB2LDAPLayout.dbColAsLDAPKey( key ), 
						   entrySought.get(key) );
				
			}//for
			
		}//if
		
		checkArgument( entry.containsKey(UID_KEY), "entry was not assigned "
				+ "uid key: " + UID_KEY );
		
		final Iterator<ImmutableMap<String,String>> ldapEntries = LDAPOrgUnit
				.getList()
				.iterator();
		
		while( ldapEntries.hasNext() )
		{
			ImmutableMap<String,String> entryInLdap = ldapEntries.next();
			
			checkArgument( entryInLdap.containsKey(UID_KEY), 
						   "uid field: " 
						   + UID_KEY 
						   + "not found in " 
						   + entryInLdap );
			
			if( entry.get(UID_KEY).equals( entryInLdap.get(UID_KEY) ) )
			{
				return ImmutableMap.copyOf( entryInLdap );
			
			}
			else
			{	
				_log.finest( "seeking ldap for entry, " 
						+ entry.get(UID_KEY) 
						+ " != " 
						+ entryInLdap.get(UID_KEY) );
				
			}//if
			
		}//while

		throw new CriticalException( "entry not found in LDAP: " + entry );
		
	}//method
	

	/**
	 * Delete an entry from LDAP.<p>
	 * 
	 * @param entryDn - full LDAP DN as String
	 * 
	 * @return  Boolean result: true for successful deletion, false for LDAP 
	 * 	or network exception
	 */
	
	public static Boolean deleteEntry( final String entryDn )
	{
		checkNotNull( entryDn, "null entry DN, cannot delete" );
		
		checkArgument( entryDn.length() > 0, "empty entry DN, cannot delete" );

		_log.fine("deleting entry: " + entryDn);
		
		Boolean result = false;
		
		try
		{
			if( ! isPresent(entryDn) )
			{
				_log.warning("attempt to delete entry not found in ldap: " 
						+ entryDn);
				
				return result;
				
			}//if
			
			final DeleteRequest delReq = new DeleteRequest( entryDn );
		
		    if( LDAPConnector.con().delete( delReq ).getResultCode() == 
				ResultCode.SUCCESS )
		    {
		    	result = true;
		    	
		    }//if
		    
		}
		catch( final LDAPException|GeneralSecurityException err)
		{
			new CriticalException(err).accept( new ErrorLoggingVisitor() );			
			
		}//try
		
		return result;
		
	}//method
	

	/**
	 * Write entry to LDAP.<p>
	 * 
	 * Supports multiple value for certain fields as permitted by LDAP.<p>
	 * 
	 * Convenience method version without DN parameter.<p>
	 * 
	 * @param entryAttributes - Map&#60;String,String&#91;&#93;&#62; with LDAP
	 * 	keys and values
	 * 
	 * @param overrwrite  optional, any argument provided causes overwrite in 
	 * 	LDAP, requiring existing entry
	 * 
	 * @return  Boolean result: true for success, false for exceptions
	 * 
	 * @throws LDAPException  general LDAP or network error
	 * 
	 * @throws GeneralSecurityException  SSL socket or other permission error
	 * 
	 * @throws LDIFException  error with entry, incomplete or improper LDAP 
	 * 	attributes
	 * 
	 * @see #writeEntry(String, Map, String...)
	 */
	
	public static Boolean writeEntry( 
			final Map<String,String[]> entryAttributes, 
			final String... overrwrite ) 
					
			throws LDAPException, 
				   LDIFException, 
				   GeneralSecurityException, 
				   SQLException
	{
		return writeEntry( findDN( entryAttributes, 0 ), 
						   entryAttributes, 
						   overrwrite );
		
	}//method

	
	/**
	 * Write entry to LDAP.<p>
	 * 
	 * Supports multiple value for certain fields as permitted by LDAP.<p>
	 * 
	 * @param entryDn - full LDAP DN: uid=doe_j,ou=thegroup,dc=domain,dc=com
	 * 
	 * @param entryAttributes - Map&#60;String,String&#91;&#93;&#62; with LDAP
	 * 	keys and values
	 * 
	 * @param overrwrite  optional, any argument provided causes overwrite in 
	 * 	LDAP, requiring existing entry
	 * 
	 * @return  Boolean result: true for success, false for exceptions
	 * 
	 * @throws LDAPException  general LDAP or network error
	 * 
	 * @throws GeneralSecurityException  SSL socket or other permission error
	 * 
	 * @throws LDIFException  error with entry, incomplete or improper LDAP 
	 * 	attributes
	 * 
	 * @see #writeEntry(Map, String...)
	 */
	
	public static Boolean writeEntry( final String entryDn, 
			final Map<String,String[]> entryAttributes, 
			final String... overrwrite )
	
			throws LDAPException, 
			   	   GeneralSecurityException, 
			   	   LDIFException
	{
		checkNotNull( entryAttributes, "Map of ldap entry attributes " + 
				"required" );
		
		checkArgument( entryAttributes.size() > 0, "empty ldap attributes " +
				"Map collection" );

		/* incoming map may be read-only, copy to mutable */
		
		Map<String,String[]> entryAttributesCopy = new HashMap<>();
		
		entryAttributesCopy.putAll( entryAttributes );
		
		/* add necessary attributes required by LDAP schema */
		
		if( ! entryAttributesCopy.containsKey("objectClass") )
		{
			entryAttributesCopy.put("objectClass", 
									new String[]{"inetOrgPerson"});
			
		}//if
		
		Collection<Attribute> attributes = new ArrayList<>();
		
		for( String ldapAttrKey : entryAttributesCopy.keySet() )
		{
			/* 
			 * prevent attribute array from containing uid as is not allowed 
			 * by ldap for replace of attributes
			 */
			
			if( ldapAttrKey != null &&
					
			  ! ldapAttrKey.equals(Config.settings()
										 .getProperty("ldap-member-id")) )
			{
				attributes.add( new Attribute(
						ldapAttrKey,
						entryAttributesCopy.get(ldapAttrKey)) );
			
			}//if
			
		}//for

		if( overrwrite.length == 0 )
		{
			final AddRequest addReq = new AddRequest( entryDn, attributes );
	
			return LDAPConnector.con().add( addReq ).getResultCode() == 
					ResultCode.SUCCESS ? true : false;  
	
		}
		else
		{
			Boolean modResult = true;
			
			for( String attrKey : entryAttributesCopy.keySet() )
			{
				ModifyRequest modReq = new ModifyRequest( entryDn, 
						new Modification(ModificationType.REPLACE, 
								attrKey, 
								entryAttributesCopy.get(attrKey)) );

				if( LDAPConnector.con().modify( modReq )
						.getResultCode() != ResultCode.SUCCESS )
				{
					modResult = false;

					_log.severe("error modifying: " + attrKey 
							+ "=" 
							+ entryAttributesCopy.get(attrKey));

					break;

				}//if					
			}//for
			
			return modResult;
			
		}//if
			
	}//method

	
	/**
	 * Convert collection of LDAP attributes into LDAP form suitable for 
	 * 	writing to LDAP.<p>
	 * 
	 * If required, replaces database columns with LDAP keys. Converts 
	 * 	attribute values into String arrays for LDAP write operation.<p>
	 * 
	 * @param dbEntry - database entry, as specified in database->LDAP layout
	 * 
	 * @return  ImmutableMap&#60;String,String&#91;&#93;&#62; containing LDAP 
	 * 	form of entry attributes
	 * 
	 * @throws SQLException  general database or network error
	 * 
	 * @see DB2LDAPLayout
	 */
	
	
	public static ImmutableMap<String,String[]> convertDBEntryToLDAP(
			
			final Map<String,String> dbEntry ) 
			
			throws SQLException	
	{
		checkNotNull( dbEntry, "null db entry" );
		checkArgument( dbEntry.size() > 0, "empty db entry" );
		
		final Map<String,String[]> entryConverted = new HashMap<>();
		
		for( String dbKey : dbEntry.keySet() )
		{
			String key = dbKey;
			
			/* accommodate if database columns used in lieu of LDAP keys */
			
			if( ! dbEntry.containsKey( UID_KEY ))
			{
				key = DB2LDAPLayout.dbColAsLDAPKey( dbKey );
				
			}//if
			
			entryConverted.put( key, new String[]{dbEntry.get( dbKey )});
			
		}//for
		
		checkArgument( entryConverted.size() == dbEntry.size(), 
				"failed to convert entry, size of new entry: " 
				+ entryConverted.size() 
				+ " does not match: " 
				+ dbEntry.size() );
		
		return ImmutableMap.copyOf( entryConverted );
		
	}//method
	
	
	@Override
	public void close() throws SQLException
	{
		LDAPConnector.disconnect();
		
		_database.disconnect();
		
	}//method
	
	
}//class


